Skip to content

feat(websocket): flush the log buffer when a server is unreachable - #1132

Merged
aqandrew merged 13 commits into
mainfrom
aqandrew/client-978-flush-buffer-on-unreachable
Oct 7, 2026
Merged

aqandrew merged 13 commits into
mainfrom
aqandrew/client-978-flush-buffer-on-unreachable

Conversation

@aqandrew

Copy link
Copy Markdown
Contributor

Closes #1112 (deferred from DEVEX-669 / #1100).

Problem

When a server is simply unreachable, the reconnecting WebSocket keeps retrying with backoff and never reaches a terminal failure reason, so BufferingLogger is never auto-flushed. A user reproducing a "can't connect / hangs on connecting" problem gets no buffered detail in their support bundle, even though plenty of below-level context was captured.

What this does

  • Tracks consecutive failed connect attempts on ReconnectingWebSocket (#consecutiveConnectFailures), incremented once per scheduled retry (both failure paths funnel through scheduleReconnect, and the initial-connect failure counts too).
  • Once the count reaches MAX_RECONNECT_FAILURES_BEFORE_FLUSH = 6, it flushes the buffer once via onConnectionFailure("unreachable", route), logs a warn breadcrumb, and emits a new connection.unreachable telemetry event.
  • Once per outage: the === N check fires exactly once; a successful open (or a user-initiated resume from DISCONNECTED) resets the counter, so a later outage flushes again.
  • Adds "unreachable" to ConnectionStateReason and documents connection.unreachable in EVENTS.md, so the reason is a real, queryable signal rather than a phantom union value.
  • Changelog entry under ## Unreleased.

Why 6

With the default backoff (250ms doubling to a 30s cap), the 6th attempt lands after ~15s of retrying: past a transient blip of one or two retries, before the 30s cap, and before a user reproducing a hang would typically give up. It's a module constant for now (easy to tune in review).

Testing

  • pnpm format:check, pnpm typecheck, pnpm lint clean.
  • New reconnectingWebSocket tests: flushes once at N with the unreachable reason; not before N; no re-flush while still stuck; resets after a successful open so a later outage flushes again; a transient outage that recovers before N never flushes; and connection.unreachable is emitted once. Plus a focused WebSocketTelemetry.unreachable unit test.
Implementation plan & decisions

Recommendations settled on the issue's open questions

  • Value of N: count-based, N = 6, module constant (not a setting). ~15s of retrying with the default backoff; deterministic and testable; time-based rejected since backoff already caps at 30s (YAGNI).
  • Counter location / reset: private #consecutiveConnectFailures on ReconnectingWebSocket, incremented at the top of scheduleReconnect; reset in the open handler and the DISCONNECTED-resume block.
  • Guard against repeated flushes: flush only when the counter is exactly equal to N (=== N); increments by 1 so it fires once per episode; a successful open resets it.
  • Reason / route: dedicated "unreachable" reason (flush key unreachable <route>), added to ConnectionStateReason and EVENTS.md, and surfaced as a real connection.unreachable telemetry event so it isn't a phantom union value.

Changes

  • src/websocket/reconnectingWebSocket.ts — constant, counter field, flush-at-N in scheduleReconnect, resets on open/resume.
  • src/instrumentation/websocket.ts — "unreachable" reason + unreachable(route, attempts) emitting connection.unreachable.
  • src/instrumentation/EVENTS.md, CHANGELOG.md — docs.
  • Tests in test/unit/websocket/reconnectingWebSocket.test.ts and test/unit/instrumentation/websocket.test.ts.

🤖 Generated with Coder Agents on behalf of @aqandrew.

An unreachable server keeps retrying with backoff and never reaches a
terminal failure reason, so the connection log buffer was never flushed and
a user reproducing a "hangs on connecting" problem got no buffered detail in
their support bundle.

Track consecutive failed connect attempts on ReconnectingWebSocket and, once
they reach a threshold (6, ~15s of retrying with the default backoff: past a
transient blip, before the 30s cap), flush the buffer once with an
"unreachable" reason and emit a connection.unreachable telemetry event. The
`=== N` check keeps it to one flush per outage; a successful open resets the
counter so a later outage flushes again.

Closes #1112.
@linear-code

linear-code Bot commented Sep 30, 2026

Copy link
Copy Markdown

CLIENT-978

@aqandrew
aqandrew marked this pull request as ready for review October 1, 2026 19:40
@aqandrew
aqandrew requested a review from EhabY October 1, 2026 19:40

@EhabY EhabY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good to me, well-tested and simple change but I am unsure if we should terminate the connection instead of just flushing the buffer

Comment thread src/instrumentation/EVENTS.md Outdated
Comment thread src/websocket/reconnectingWebSocket.ts
Comment thread src/websocket/reconnectingWebSocket.ts Outdated
Comment thread test/unit/websocket/reconnectingWebSocket.test.ts Outdated
Comment thread test/unit/websocket/reconnectingWebSocket.test.ts Outdated
Comment thread test/unit/websocket/reconnectingWebSocket.test.ts
Comment thread test/unit/websocket/reconnectingWebSocket.test.ts
Comment thread src/websocket/reconnectingWebSocket.ts
…ffer flush

The buffer flush is a logging concern, not part of the telemetry event, so the
EVENTS.md entry only describes when the event fires and its attributes.
Reaching the unreachable threshold only flushes the buffer; the loop keeps
retrying at the backoff cap and never gives up, so a socket recovers after a
sleep or long outage. The onConnectionFailure doc still said it fires only on
terminal failures, and the warning read like the socket gave up.

Reword both, note it in EVENTS.md and the changelog, and pin the behavior with
a five-minute outage test that stays in AWAITING_RETRY without re-flushing and
then reconnects.
The 100ms used for the backoff options and every timer advance was the same
value; a BACKOFF_MS constant makes it clear each advance is one failed attempt.
setupUnreachable hand-rolled a failing factory and called
ReconnectingWebSocket.create directly. Forward the backoff and jitter options
through FactoryOptions/fromFactory and build it on
createReconnectingWebSocketWithErrorControl, toggling failures with
setFactoryError.
Four tests repeated startOutage() plus N-1 failed attempts. A failUntilFlush()
helper replaces the loop; the threshold test keeps its explicit loop because it
asserts nothing flushes before N.
The event's route and attempts are covered by the WebSocketTelemetry unit test,
so the reconnecting test just checks the socket emits it at the flush.
@aqandrew
aqandrew enabled auto-merge (squash) October 7, 2026 19:40
@aqandrew
aqandrew requested a review from EhabY October 7, 2026 19:40
Comment thread src/websocket/reconnectingWebSocket.ts Outdated
Comment thread src/instrumentation/websocket.ts Outdated
Comment thread src/websocket/reconnectingWebSocket.ts
@EhabY
EhabY disabled auto-merge October 7, 2026 19:52
"unreachable" is only passed to onConnectionFailure and never dispatched, so it
can't appear as a telemetry reason, yet EVENTS.md listed it as one. Add a
ConnectionFailureReason union (ConnectionStateReason | "unreachable") for the
failure callback in ReconnectingWebSocket and CoderApi, and drop it from the
state reasons and the EVENTS.md list.
@aqandrew
aqandrew merged commit ecbd503 into main Oct 7, 2026
13 checks passed
@aqandrew
aqandrew deleted the aqandrew/client-978-flush-buffer-on-unreachable branch October 7, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flush the connection log buffer after N failed reconnect attempts against an unreachable server

2 participants